fix(scripts): make bash branch-name sanitizing match the Python and PowerShell twins - #4286
Conversation
There was a problem hiding this comment.
Pull request overview
Aligns Bash branch-name sanitization with Python and PowerShell across locales and platforms.
Changes:
- Uses locale-independent sanitization, portable
sed, and safeprintf. - Adds parity coverage for locales, repeated separators, and option-like names.
- Introduces locale probing for tests.
Reviewed changes
Copilot reviewed 5 out of 5 changed files in this pull request and generated 1 comment.
Show a summary per file
| File | Description |
|---|---|
scripts/bash/create-new-feature.sh |
Fixes core Bash sanitization. |
extensions/git/scripts/bash/create-new-feature-branch.sh |
Applies equivalent Git extension changes. |
tests/test_create_new_feature_python_parity.py |
Adds core parity tests. |
tests/extensions/git/test_git_extension_python_parity.py |
Expands extension parity tests. |
tests/parity_helpers.py |
Adds locale detection helper. |
💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 8 out of 8 changed files in this pull request and generated 2 comments.
Suppressed comments (1)
Previously missed (1) — in code that hasn't changed since the last review.
tests/extensions/git/test_git_extension_python_parity.py:179
- This extension parity table covers the separator-collapse fix but not the
echo-option fix made in the duplicated extension Bash script. Only the core script gets-n/-e/-Ecoverage, so the extension copy could regress independently while CI remains green. Add those values here as well.
("User_Auth!", "001-user-auth"),
("User__Auth!!", "001-user-auth"),
("auth -- v2", "001-auth-v2"),
],
ids=["single_separators", "repeated_separators", "separator_run"],
mnriem
left a comment
There was a problem hiding this comment.
Please address Copilot feedback
The ASCII-lookaround fix in scripts/powershell/create-new-feature.ps1 and extensions/git/scripts/powershell/create-new-feature-branch.ps1 had no PowerShell regression coverage: the accented-acronym parity cases invoked only bash and Python, and the existing PowerShell acronym tests used ASCII-separated words, so a regression there would have passed CI. Adds a three-way bash/Python/pwsh assertion for 'Fix eDBe sync' in the core parity suite and a pwsh arm to the extension parity test, both asserting 001-fix-db-sync.
|
@mnriem Copilot's two open comments are addressed in 02e2c1b — PowerShell regression coverage for the ASCII acronym boundary:
Verified locally with PowerShell 7.6.5 on Linux. Reverting the two Full suite: 7323 passed, 10 skipped. The single failure, |
There was a problem hiding this comment.
🟡 Changes recommended
The locale and option-like short-name fixes are not fully covered for each duplicated Bash implementation.
Once you've addressed the issues Copilot identified, you can request another Copilot review.
Review details
- Files reviewed: 8/8 changed files
- Comments generated: 3
- Review effort level: Balanced
|
Please address Copilot feedback |
There was a problem hiding this comment.
🟢 Approval recommended
The implementation consistently fixes the reported portability defects and includes targeted cross-language regression coverage.
Review details
- Files reviewed: 8/8 changed files
- Comments generated: 0 new
- Review effort level: Balanced
|
@mnriem Copilot's three new comments are addressed in 247d468. All three were the same class of gap:
Each was verified by reverting the specific fix on its own and confirming the new assertions fail: 8 failed reverted, 8 passed restored. Also, now that #4280 is merged, the pre-existing |
|
Please resolve conflicts |
…ale-parity # Conflicts: # tests/test_create_new_feature_python_parity.py
There was a problem hiding this comment.
🟢 Approval recommended
The implementations are aligned across script variants and the reported platform-specific regressions have targeted parity coverage.
Review details
- Files reviewed: 8/8 changed files
- Comments generated: 0 new
- Review effort level: Balanced
|
@mnriem Conflicts resolved in b5a45e3, merged Only
Full suite after the merge: 7458 passed, 191 skipped. The single local failure is |
There was a problem hiding this comment.
🟢 Approval recommended
The platform-specific sanitization defects are consistently fixed across duplicated implementations with targeted parity coverage.
Review details
- Files reviewed: 8/8 changed files
- Comments generated: 0 new
- Review effort level: Balanced
|
Thank you! |
…first parent The guard diffed github.event.pull_request.base.sha against the checked-out pull-request merge commit. Those two are not built from the same base: the payload SHA is the base-branch tip from when the PR was opened and is not refreshed when the base moves, while refs/pull/N/merge is rebuilt against the current tip. Any base-branch drift between the two is then attributed to the PR. Observed on github#4395 itself: base.sha was main as of Sep 1 (3eec154), the merge commit was built on main as of Sep 3 (db64869), and two unbumped bundled-extension changes merged in between (github#4413 touching agent-context, github#4286 touching git) failed the guard on a PR that never touched either. Fetch the merge commit with depth 2 so both parents are available and diff against HEAD^1, which is by construction the base the merge was built on. Fail loudly if HEAD is not a merge commit. The script's CLI is unchanged; a regression test reproduces the trap in a throwaway repo (the stale base blames the drift on the PR, HEAD^1 does not). Refs github#4345 Assisted-by: Claude Code (model: claude-fable-5-1) Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…n changes (#4395) * ci(extensions): guard bundled extension changes behind a version bump Bundled extensions only reach existing installs through a version bump: `specify extension update` compares the semver in extensions/catalog.json against the installed copy and reports "Up to date" whenever they match. Content changes shipped without a bump go silently stale on every project that already installed the extension (#4345). This guard turns "please remember to bump" into a merge requirement. - `.github/scripts/check_extension_version_bump.py` fails a PR that changes files under `extensions/<id>/` for a catalog-listed extension without increasing that extension's `extension.yml` version (PEP 440 comparison, the same semantics `extension update` uses), and requires `extensions/catalog.json` to stay in sync with each manifest. Non-catalog extensions (the `selftest` fixture and the `template` scaffold) are exempt. - `extension-version-guard.yml` runs the check on pull requests touching `extensions/**`, diffing the PR base against HEAD. - Contract tests pin the working-tree half of the invariant (catalog/manifest version sync, bundled entries shipping a directory); guard-script tests pin the failure behavior against real throwaway git repositories so a parsing change cannot silently disable the guard while CI stays green. Split out of #4351 (part 3 of the series requested in review); refs #4345. Assisted-by: Claude Code (model: claude-fable-5) Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(ci): make the extension version guard usable as a required check and quoting-proof Address Copilot review round 1 on #4395. Workflow: drop the `paths: extensions/**` filter. A required status check that is skipped by path filtering stays in "Expected" state and blocks every PR that does not touch extensions/**, which defeats the point of making the guard a merge requirement. The job now runs on every pull request; the script already reports success when nothing under extensions/ changed, so unrelated PRs pass in one short job. Script: read the changed-path list with `git diff --name-only -z`. With git's default core.quotePath, a path containing non-ASCII or control characters is C-quoted with the quotes included (`"extensions/demo/caf\303\251.txt"`), so its first component was no longer `extensions` and an unbumped change to such a file escaped the guard. NUL-delimited output is emitted verbatim; paths are decoded with surrogateescape so an undecodable byte cannot crash the check, and only the ASCII `extensions/<id>/` prefix is ever interpreted. Tests: pin both behaviors. The non-ASCII case fails against the previous script and passes now; the no-extension-changes case backs the workflow change. core.quotePath is pinned to true in the fixture repo so the regression exercises the quoting path even where a developer's global config disables it. Refs #4345 Assisted-by: Claude Code (model: claude-fable-5-1) Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> * fix(ci): validate a new extension's version even without a base to compare Address @mnriem's review on #4395. For a brand-new extension the guard returned early at "no base manifest" before ever parsing the head version, and the catalog check only tests string equality. A new extension shipped with e.g. `not-a-version` in both extension.yml and catalog.json therefore passed the guard, although ExtensionManifest rejects a version packaging cannot parse (ValidationError "Invalid version") and `extension update` skips catalog entries whose version is invalid - the extension would be uninstallable and never updatable. Parse the head version before the new-extension early return and fail closed with a dedicated message when it is not PEP 440. The base version is now parsed separately, so an unparseable base still fails with the "could not compare versions" message. Tests: add test_new_extension_with_unparseable_version_fails (fails against the previous script, passes now) and point the existing unparseable-version test at the new head-version message, which fires first for that scenario. Refs #4345 Assisted-by: Claude Code (model: claude-fable-5-1) Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> * fix(ci): diff the extension version guard against the merge commit's first parent The guard diffed github.event.pull_request.base.sha against the checked-out pull-request merge commit. Those two are not built from the same base: the payload SHA is the base-branch tip from when the PR was opened and is not refreshed when the base moves, while refs/pull/N/merge is rebuilt against the current tip. Any base-branch drift between the two is then attributed to the PR. Observed on #4395 itself: base.sha was main as of Sep 1 (3eec154), the merge commit was built on main as of Sep 3 (db64869), and two unbumped bundled-extension changes merged in between (#4413 touching agent-context, #4286 touching git) failed the guard on a PR that never touched either. Fetch the merge commit with depth 2 so both parents are available and diff against HEAD^1, which is by construction the base the merge was built on. Fail loudly if HEAD is not a merge commit. The script's CLI is unchanged; a regression test reproduces the trap in a throwaway repo (the stale base blames the drift on the PR, HEAD^1 does not). Refs #4345 Assisted-by: Claude Code (model: claude-fable-5-1) Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> * fix(ci): validate every catalog version, not only those of changed extensions Address the "needs a closer look" items from the Copilot reviews on #4395. Invariant 2 skipped catalog entries without an in-repo directory before parsing their version, and never parsed the in-repo manifest version at all - PEP 440 validation lived only in Invariant 1, which sees just the IDs with changed files under extensions/<id>/. Two gaps followed: - a hosted (catalog-only) entry could carry `"version": "not-a-version"` and pass, although `extension update` skips exactly such entries when packaging.Version fails; - promoting an existing uncataloged directory by adding only its catalog entry never entered Invariant 1, so a matching invalid string in manifest and catalog passed the plain equality check. Invariant 2 now runs over every catalog entry: the catalog version must be a non-empty string that parses as PEP 440, and for entries with an in-repo directory the manifest version must parse as well before the equality check. The catalog-side parse alone closes the promotion gap (a valid catalog string plus equality forces a valid manifest string); the manifest parse makes the failure name the manifest precisely. Tests: `_run_guard` now omits the head argument by default, matching the workflow's one-argument invocation, with a separate test for the optional HEAD_REF; new cases cover the hosted-entry and both promotion shapes (the two invalid ones fail against the previous script) plus a valid promotion that must keep passing. The working-tree contract test gains the matching every-catalog-version-parses check. Refs #4345 Assisted-by: Claude Code (model: claude-fable-5-1) Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> --------- Co-authored-by: Jakub Baranowski <cr4zybaran@gmail.com> Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
Description
clean_branch_name/generate_branch_namein the bash twins produce branch andspecs/directory names that differ from the Python and PowerShell twins. Threeindependent defects, all in the same sanitizing pipeline, all in two copies of it
(
scripts/bash/create-new-feature.shandextensions/git/scripts/bash/create-new-feature-branch.sh).1.
[^a-z0-9]is locale-dependent (the user-visible one)glibc resolves a bracket-expression range through the locale's collation
table, so under
en_US.UTF-8thea-zinsed 's/[^a-z0-9]/-/g'coversaccented lowercase letters. Under
C.UTF-8/ POSIX it does not.Same repo, same command, same description — only
LANGdiffers:en_US.UTF-8is the default on macOS Terminal and most Linux desktops; CIrunners generally run in the POSIX locale, which is why this never showed up in
the suite. Two teammates running
/speckit.specifyon the same description gettwo different
specs/directories, and the second one's/speckit.plancannotfind the first one's feature. The git extension checks out a branch with those
bytes in it.
Same divergence for Spanish (
añadirvsadir), German (prüfungvsfung),Portuguese, Nordic — anything in Latin-1 Supplement / Latin Extended-A.
2.
sed 's/-\+/-/g'does not collapse anything on macOS\+is a GNU extension, not POSIX BRE. POSIX/BSDsedreads it as a literal+, so the collapse step is a no-op there:--short-name 'My Fancy!! Name'therefore yields001-my-fancy---nameon macOSand
001-my-fancy-nameon Linux and from the Python/PowerShell twins. The repoalready knew:
tests/extensions/git/test_git_extension_python_parity.pycarriedand deliberately tested only inputs that could not hit it. That comment and that
restriction are removed here.
3.
echo "$name"eats-n/-e/-EThe raw value went through
echo, so those three short names were consumed asoptions and produced an empty suffix (
001-) where Python produces001-n.Fix
LC_ALL=Cfor the sanitizing pipeline (byte-exact classes, and ASCII wordboundaries for the
grep -qwacronym probe, matching the Python twin's(?<![0-9A-Za-z_])lookarounds), the portable--*in place of\+, andprintf '%s\n'in place ofecho. The same edit in both copies of thefunction. Behaviour on ASCII input in the POSIX locale — which is what CI has
been exercising — is unchanged.
local -xscopes and exports the override to the pipeline's children only; itis restored on return, so nothing else in the script or the caller's environment
sees it.
Follow-up from review: ASCII acronym boundaries
Copilot spotted that scoping
LC_ALL=Cover the whole ofgenerate_branch_namealso changes the
grep -qwacronym probe, and that the extension's Python twinstill used a Unicode
\bthere. That was correct:Under
LC_ALL=Can accented letter is a non-word byte, soDBhas boundariesand survives; Python's
\band .NET's\bare Unicode-aware, treatéDBéasone word, and drop it. Only words shorter than three characters reach this
probe, so the window is narrow, but the twins genuinely disagreed.
The core Python twin had already settled this the ASCII way
(
scripts/python/create_new_feature.py:185, with a comment saying it mirrorsbash's
grep -qw). Three files were still using\band are now aligned to thesame explicit lookarounds:
extensions/git/scripts/python/create_new_feature_branch.pyextensions/git/scripts/powershell/create-new-feature-branch.ps1scripts/powershell/create-new-feature.ps1The last one is beyond the reported comment: it had the identical
\bagainst acore Python twin that already used ASCII lookarounds, so the same divergence
existed in core. Fixing only the extension would have left the two halves
inconsistent with each other.
Covered by
test_acronym_adjacent_to_non_ascii_matches_pythonin the extensionparity suite and an
acronym_next_to_non_asciicase intest_python_branch_name_generation_matches_bash.Testing
uv run specify --helpuv sync && uv run pytestFull suite on this branch:
7151 passed, 181 skipped, 1 failedon Linux /Python 3.14. On
mainthe same run is7136 passed, 181 skipped, 1 failed;this branch adds 15 test cases (7136 + 15 = 7151).
The one failure,
tests/contract/test_bundle_cli.py::test_build_escapes_markup_in_output_path,is unrelated and pre-existing on
main: it asserts across a Rich wrap point, soit passes or fails depending on how long the runner's temp path is. Fixed
separately in #4280.
Fail-before / pass-after, with the sources reverted and the new tests kept:
test_bash_branch_name_ignores_locale_collation(3 params)test_python_dash_prefixed_short_name_matches_bash(3 params)TestCreateFeatureBranchParity::test_branch_name_ignores_locale_collation(2 params)test_bash_collapses_repeated_separators(2 params)test_short_name_cleaning[repeated_separators, separator_run]The two separator tests are the macOS guard — on a GNU-sed runner they pass
either way, which is exactly why the gap survived. They will exercise the real
thing on the
macos-latestleg of the matrix.The locale tests probe
sedunderen_US.UTF-8and skip when the environmentcannot reproduce collation-ordered ranges (locale not installed, non-glibc libc,
Git-for-Windows), rather than asserting against a locale name that may not exist.
Manual test results
Agent: Claude Code | OS/Shell: Ubuntu 24.04.4, bash 5.2.21,
LANG=en_INScaffolded from this branch with
specify init <dir> --integration claude --extension git, so the project undertest carries the patched scripts.
/speckit-specify Ajouter la réservation hôtelière(accented)specs/001-hotel-booking/, branch001-hotel-booking; spec dir and branch agree/speckit-git-feature(same description)create-new-feature.sh --json --dry-run --short-name "réservation hôtelière"002-r-servation-h-teli-reextensions/git/.../create-new-feature-branch.sh --json --short-name "réservation hôtelière"002-r-servation-h-teli-rechecked outWorth noting for reviewers: in the two slash-command runs the agent summarised
the French description into an ASCII short name of its own ("hotel-booking"),
so those runs confirm the commands still work but do not reach the accented
path. The last two rows drive the scripts with a non-ASCII
--short-namedirectly, which is what the skills do internally, and that is where the
divergence used to appear.
en_INis a stock desktop locale and reproduces itexactly like
en_US.UTF-8.AI Disclosure
Code and tests generated with Claude Code. I reviewed the diff, reproduced the
locale divergence, ran the suite, and understand what the change does.